8-3 菜单查询与删除(创建自定义Pipe)
菜单查询的实现
菜单模块的查询操作包含 findOne、findAll 两个核心方法,其中 findAll 需要支持分页、嵌套关联查询和自定义扩展。
findOne 查询单个菜单
findOne 复用了创建方法中的查询逻辑,直接传入 ID 获取菜单详情:
// src/menu/menu.service.ts
async findOne(id: number) {
return this.prisma.menu.findUnique({
where: { id },
include: {
meta: true,
children: {
include: { meta: true },
},
},
});
}
typescript
findAll 分页查询与嵌套关联
findAll 方法需要处理三个核心问题:分页、嵌套 include、以及 limit 为 -1 的特殊场景。
async findAll(page: number, limit: number, extInclude?: any) {
// 构建基础 include 配置
const includeCfg = {
meta: true,
children: {
include: { meta: true },
},
...extInclude, // 支持用户自定义扩展嵌套层级
};
// 处理 limit = -1 的特殊情况(查询全部)
const pagination: any = limit === -1
? {}
: { skip: (page - 1) * limit, take: limit };
return this.prisma.menu.findMany({
...pagination,
include: includeCfg,
});
}
typescript
关键设计点:
extInclude参数通过对象展开运算符合并到默认的 include 配置中,允许调用方自定义第三层、第四层的嵌套查询limit === -1时跳过分页参数,返回所有数据
自定义 Pipe:CustomParseIntPipe
NestJS 内置的 ParseIntPipe 不接受负数,当 limit 需要传 -1 表示查询全部时就会报错。为此需要创建一个自定义 Pipe。
创建 Pipe
nest g pipe common/pipes/custom-parse-int-pipe --no-spec --flat
bash
Pipe 实现
// src/common/pipes/custom-parse-int-pipe.ts
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
interface CustomParseIntPipeOptions {
optional?: boolean; // 允许参数为空
}
@Injectable()
export class CustomParseIntPipe implements PipeTransform<string, number | undefined> {
constructor(private options?: CustomParseIntPipeOptions) {}
transform(value: string): number | undefined {
// 如果设置了 optional 且值为空,返回 undefined
if (this.options?.optional && !value) {
return undefined;
}
const parsedValue = parseInt(value, 10);
// 验证:必须是有效数字,或者为 -1,或者不小于 0
if (isNaN(parsedValue) || (parsedValue !== -1 && parsedValue < 0)) {
throw new BadRequestException('参数不合法');
}
return parsedValue;
}
}
typescript
与内置 ParseIntPipe 的区别:
| 特性 | ParseIntPipe | CustomParseIntPipe |
|---|---|---|
| 负数处理 | 拒绝所有负数 | 允许 -1 |
| 可选参数 | 不支持 | 通过 optional 选项支持 |
| 错误信息 | 通用英文提示 | 可自定义中文提示 |
在 Controller 中使用
// src/menu/menu.controller.ts
import { CustomParseIntPipe } from '../common/pipes/custom-parse-int-pipe';
@Controller('menu')
export class MenuController {
@Get()
findAll(
@Query('page', new CustomParseIntPipe({ optional: true })) page?: number,
@Query('limit', new CustomParseIntPipe({ optional: true })) limit?: number,
@Query('ext') ext?: string,
) {
// 处理 ext 参数(JSON 字符串 → 对象)
let parsedExt: any;
if (ext) {
try {
parsedExt = JSON.parse(ext);
} catch {
throw new BadRequestException('无效的 JSON 数据格式: ext');
}
}
return this.menuService.findAll(page ?? 1, limit ?? 10, parsedExt);
}
}
typescript
Query 参数传递复杂对象
当需要在 GET 请求的 Query 中传递复杂对象时,需要经过序列化和反序列化处理:
前端 → JSON.stringify({ children: { include: { meta: true } } })
→ URL 编码后附加到查询字符串
→ 后端接收 JSON 字符串
→ JSON.parse() 转为对象
text
安全的反序列化
// 在 Controller 或 Service 中处理
let parsedExt: any;
if (ext) {
try {
parsedExt = JSON.parse(ext);
} catch (error) {
throw new BadRequestException('无效的 JSON 数据格式: ext');
}
}
typescript
使用 try-catch 包裹 JSON.parse 防止格式错误导致服务崩溃。
菜单删除与递归级联
菜单数据具有树形结构(父级-子级),删除一个菜单时需要同时删除其所有子级和关联的 meta 数据。
递归查询子级 ID
由于只知道父级菜单的 ID,需要通过递归查询获取所有子级 ID:
// src/menu/menu.service.ts
async remove(id: number) {
// 1. 查询当前菜单(含子级)
const menu = await this.prisma.menu.findUnique({
where: { id },
include: { children: true },
});
// 2. 收集所有需要删除的 ID
const idsToDelete: number[] = [id];
// 3. 递归收集子级 ID
const collectMenuIds = async (menuId: number) => {
const item = await this.prisma.menu.findUnique({
where: { id: menuId },
include: { children: true },
});
if (item?.children?.length) {
await Promise.all(
item.children.map(async (child) => {
idsToDelete.push(child.id);
await collectMenuIds(child.id); // 递归
}),
);
}
};
await collectMenuIds(id);
console.log('IDs to delete:', idsToDelete);
// 4. 使用事务删除 meta 和 menu 数据
return this.prisma.$transaction(async (prisma) => {
// 先删除关联的 meta 数据
await prisma.meta.deleteMany({
where: { menuId: { in: idsToDelete } },
});
// 再删除 menu 数据
return prisma.menu.deleteMany({
where: { id: { in: idsToDelete } },
});
});
}
typescript
删除流程图解
假设有如下菜单树:
Menu(id=10)
├── Menu(id=11)
├── Menu(id=12)
│ └── Menu(id=13)
text
执行 remove(10) 的过程:
1. 查询 id=10 → 获取 children: [11, 12]
↓
2. 递归 id=11 → 无 children → push(11)
↓
3. 递归 id=12 → children: [13] → push(12), push(13)
↓
4. idsToDelete = [10, 11, 12, 13]
↓
5. 事务删除:
- meta.deleteMany WHERE menuId IN [10, 11, 12, 13]
- menu.deleteMany WHERE id IN [10, 11, 12, 13]
text
为什么使用事务
删除操作涉及两张表(meta 和 menu),必须保证原子性。如果先删除了 meta 再删除 menu 时失败,会导致数据不一致。使用 prisma.$transaction 将两个删除操作包装在一个事务中,要么全部成功,要么全部回滚。
Controller 中完整的路由定义
// src/menu/menu.controller.ts
@Controller('menu')
export class MenuController {
constructor(private readonly menuService: MenuService) {}
@Post()
create(@Body() dto: CreateMenuDto) {
return this.menuService.create(dto);
}
@Get()
findAll(
@Query('page', new CustomParseIntPipe({ optional: true })) page?: number,
@Query('limit', new CustomParseIntPipe({ optional: true })) limit?: number,
@Query('ext') ext?: string,
) {
let parsedExt: any;
if (ext) {
try {
parsedExt = JSON.parse(ext);
} catch {
throw new BadRequestException('无效的 JSON 数据格式: ext');
}
}
return this.menuService.findAll(page ?? 1, limit ?? 10, parsedExt);
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.menuService.findOne(id);
}
@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {
return this.menuService.remove(id);
}
}
typescript
测试验证
使用 Bruno(或 Postman)进行接口测试:
# 查询全部(limit=-1)
GET /menu?page=1&limit=-1
# 分页查询
GET /menu?page=1&limit=10
# 带扩展查询
GET /menu?page=1&limit=10&ext={"children":{"include":{"meta":true,"children":true}}}
# 删除菜单(级联删除子菜单和 meta)
DELETE /menu/10
# 响应:{ "count": 4 } ← 删除了 10, 11, 12, 13 四条数据
bash
验证数据库中的 menu 表和 meta 表,确认对应的记录已被删除。
↑